fix(agent-server): detect all secret-bearing fields for the plaintext-save warning, not just llm.api_key - #4618
Conversation
… persistence Adds an opt-in Config.require_secret_key flag (env OH_REQUIRE_SECRET_KEY). When set, FileSettingsStore/FileSecretsStore raise MissingCipherError instead of silently downgrading to plaintext storage when secrets are present and no OH_SECRET_KEY cipher is configured. Off by default, so the zero-config self-hosted/OSS path is unchanged. Fixes #4609.
Python API breakage checks — ✅ PASSEDResult: ✅ PASSED |
REST API breakage checks (OpenAPI) — ✅ PASSEDResult: ✅ PASSED |
Coverage Report •
|
|||||||||||||||||||||||||
|
✅ Review complete. This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here. |
all-hands-bot
left a comment
There was a problem hiding this comment.
🟡 Acceptable — clean design, correct implementation, a couple of symmetric test gaps worth filling.
The config field → helper → store-constructor wiring is consistent and readable. All write paths (save, set_secret, delete_secret via _save_with_versions) properly funnel through the require_cipher guard. The update() path on FileSettingsStore inherits the check automatically because it delegates to save(). The flag is off by default so existing deployments are unaffected. Error messages are actionable.
Two symmetric tests are missing (details in inline comments), and one docstring is pure noise. Otherwise good.
[TESTING GAPS]
tests/agent_server/test_persistence_require_cipher.py— two symmetricFileSecretsStorecoverage holes (see inline).
[STYLE NOTE]
openhands-agent-server/openhands/agent_server/persistence/store.py, line 854 — redundant docstring (see inline).
[RISK ASSESSMENT]
- [Overall PR]
⚠️ Risk Assessment: 🟢 LOW
Default isFalse; no behaviour change for any existing deployment. When enabled, the guard raises immediately with a clear, actionable message before writing anything. All secret write paths are covered by the same_save_with_versionsfunnel.
VERDICT:
✅ Worth merging — functionally correct and safe. The two missing tests are worth adding before or just after merge but are not blockers.
KEY INSIGHT:
The security contract (raise before writing, never silently store plaintext when the flag is on) is correctly enforced on every write path; the only gap is symmetric backward-compat test coverage for FileSecretsStore.
This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation
Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:
- Add a
.agents/skills/custom-codereview-guide.mdfile to your branch (or edit it if one already exists) with the/codereviewtrigger and the context the reviewer is missing. See the customization docs for the required frontmatter format.- Re-request a review — the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
- When your PR is merged, the guideline file goes through normal code review by repository maintainers.
Resolve with AI? Install the iterate skill in your agent and run
/iterateto automatically drive this PR through CI, review, and QA until it's merge-ready.Was this review helpful? React with 👍 or 👎 to give feedback.
- Add symmetric FileSecretsStore coverage for the two backward-compat cases (no-secrets no-op, default-off plaintext), mirroring the existing FileSettingsStore tests. - Drop the redundant _get_require_cipher docstring — it restated the signature verbatim.
|
Addressed both review points in fad27ba: added the two symmetric FileSecretsStore tests and dropped the redundant docstring on |
|
✅ Review complete. This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here. |
all-hands-bot
left a comment
There was a problem hiding this comment.
🟡 Acceptable — Clean, minimal, well-tested opt-in guard. One improvement opportunity worth a follow-up.
Summary: This PR adds require_secret_key (OH_REQUIRE_SECRET_KEY) as an opt-in boolean flag that makes FileSettingsStore / FileSecretsStore raise MissingCipherError instead of silently falling back to plaintext when OH_SECRET_KEY is absent. The design is correct — default-off preserves the zero-config OSS path, and the guard is wired through both stores symmetrically. 8 tests exercise the real code paths across all combinations.
One gap worth a follow-up: The settings guard checks llm_api_key_is_set (LLM api_key field only). OpenHandsAgentSettings also has critic_api_key: str | SecretStr | None with its own serialize_secret serializer. A user with only critic_api_key set and require_cipher=True will silently get plaintext output — the guard won't fire. See inline comment for details. This matches the existing warning gate, so it's a pre-existing design boundary, but widening it here would make the guarantee complete.
[RISK ASSESSMENT]
- [Overall PR]
⚠️ Risk Assessment: 🟢 LOW
Pure opt-in feature withdefault=False. Default-off means zero behavior change for existing deployments. The only code that executes on the new path is an early raise inside an already-existingif not cipher:branch — no new state, no new I/O. Test coverage is solid and the backward-compat test is explicitly preserved.
VERDICT:
✅ Worth merging — core logic is sound, the critic_api_key gap is a narrow pre-existing edge case that can be addressed in a follow-up once the enterprise opt-in wiring lands.
KEY INSIGHT: The secret-presence gate (llm_api_key_is_set / custom_secrets) is inherited from the existing warning path — broadening it to cover all SecretStr fields would make require_cipher airtight.
This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation
Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:
- Add a
.agents/skills/custom-codereview-guide.mdfile to your branch (or edit it if one already exists) with the/codereviewtrigger and the context the reviewer is missing. See the customization docs for the required frontmatter format.- Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
- When your PR is merged, the guideline file goes through normal code review by repository maintainers.
Resolve with AI? Install the iterate skill in your agent and run
/iterateto automatically drive this PR through CI, review, and QA until it's merge-ready.Was this review helpful? React with 👍 or 👎 to give feedback.
PersistedSettings.has_any_secret covers both llm.api_key and verification.critic_api_key (a separate secret field on OpenHandsAgentSettings). Without this, a user who only set critic_api_key could still get plaintext storage even with require_cipher=True.
|
Addressed the critic_api_key gap in 6ee2092. Ready for another look. |
|
✅ Review complete. This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here. |
all-hands-bot
left a comment
There was a problem hiding this comment.
🔴 Taste Rating: Needs improvement — the direction is sound, but the secret-detection predicate still has security-sensitive blind spots.
[CRITICAL ISSUES]
- [openhands-agent-server/openhands/agent_server/persistence/models.py, Line 184] Security/Correctness:
has_any_secretonly checks the LLM API key and critic API key. Other settings fields serialize secrets too (agent_context.secrets, MCPenv/headers, header auth credentials, OAuth token state), sorequire_cipher=Truecan still fall through to plaintext persistence for those settings.
[TESTING GAPS]
- Add regression coverage for
FileSettingsStore(..., require_cipher=True)with no cipher whenagent_context.secretsis populated, and for at least one MCP secret-bearing field. Those tests should fail today because the guard returnsFalseand the store writes plaintext.
[RISK ASSESSMENT]
- [Overall PR]
⚠️ Risk Assessment: 🟡 MEDIUM
This PR touches security-sensitive secret persistence. The default behavior remains unchanged, but the new opt-in guard can give multi-tenant deployments a false guarantee while still allowing some settings secrets to be persisted in plaintext.
VERDICT:
❌ Needs rework: the require_secret_key guard needs to cover every settings secret serializer, not just LLM/critic keys.
KEY INSIGHT:
The store should reuse the existing serialization pipeline to detect secret-bearing settings instead of hand-maintaining a partial list of secret fields.
Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:
- Add a
.agents/skills/custom-codereview-guide.mdfile to your branch (or edit it if one already exists) with the/codereviewtrigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.- Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
- When your PR is merged, the guideline file goes through normal code review by repository maintainers.
Resolve with AI? Install the iterate skill in your agent and run
/iterateto automatically drive this PR through CI, review, and QA until it's merge-ready.Was this review helpful? React with 👍 or 👎 to give feedback.
This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation
…checklist
has_any_secret previously checked only llm.api_key + critic_api_key by
name -- MCP server env/headers, agent_context.secrets, and any future
secret-bearing field would silently fall through require_cipher=True
to plaintext storage.
Replaced with a probe cipher passed as context={"cipher": probe} to
force every secret field's own serializer down its real 'encrypted'
branch (see resolve_expose_mode), so detection reuses the actual
serialization logic instead of hand-walking the model for SecretStr
instances -- which also can't see fields like AgentContext.secrets,
whose bare-string values are plain str at rest and only become
secret-shaped inside their own field serializer at dump time.
Added regression tests for MCP env secrets and agent_context.secrets.
|
Addressed the structural gap in 091ceb5 — has_any_secret now reuses the real serialization pipeline (probe cipher forcing every field's own serializer) instead of a hand-maintained checklist, so it generalizes to MCP secrets, agent_context.secrets, and any future secret-bearing field. Ready for another look. |
|
✅ Review complete. This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here. |
all-hands-bot
left a comment
There was a problem hiding this comment.
🟢 Good taste — Elegant, focused solution that fixes a real gap in secret detection coverage.
What this does
Previously, the plaintext-storage guard only checked llm_api_key_is_set (settings) and custom_secrets non-empty (secrets), meaning MCP server env/headers, critic_api_key, agent_context.secrets, and any future secret-bearing fields were silently persisted in plaintext with no warning. This PR:
- Broadens detection via
has_any_secret, which probes the real Pydantic serialization pipeline instead of field-type inspection — correctly catchingAgentContext.secretswhose values are plainstrat rest and only become secret-shaped inside their field serializer. - Adds
require_secret_keyconfig flag (defaultFalse) that turns the warning into a hardMissingCipherErrorraise, intended for multi-tenant / production deployments. - Tests all the edge cases: critic key, MCP env, agent context secrets, empty-secrets bypass, cipher round-trips, backward compat.
One minor observation
In the no-cipher path, has_any_secret runs model_dump (probe) before save() runs model_dump again for the actual write — two serializations. Harmless given the filesystem I/O that follows, but worth noting if this path ever becomes latency-sensitive.
The PR description is empty, which makes it harder to understand the motivation from the PR UI, but the code and test comments are self-documenting enough.
[RISK ASSESSMENT]
- [Overall PR]
⚠️ Risk Assessment: 🟢 LOW
require_secret_keydefaults toFalse, so all existing behavior is fully preserved. The only behavioral difference for existing deployments is that the plaintext warning now fires for more secret types (MCP env vars, critic key, agent context secrets) — a strictly safer default. TheMissingCipherErrorpath is entirely opt-in.
VERDICT:
✅ Worth merging — Clean implementation, solid tests, backward-compatible.
KEY INSIGHT:
Using the real serialization pipeline as a probe (a fake cipher that sets a flag on encrypt()) is the right design: it guarantees coverage over all current and future secret-bearing fields without maintaining a fragile field checklist.
Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:
- Add a
.agents/skills/custom-codereview-guide.mdfile to your branch (or edit it if one already exists) with the/codereviewtrigger and the context the reviewer is missing. See the customization docs for the required frontmatter format.- Re-request a review — the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
- When your PR is merged, the guideline file goes through normal code review by repository maintainers.
Resolve with AI? Install the iterate skill in your agent and run
/iterateto automatically drive this PR through CI, review, and QA until it's merge-ready.Was this review helpful? React with 👍 or 👎 to give feedback.
This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation
|
✅ Review complete. This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here. |
all-hands-bot
left a comment
There was a problem hiding this comment.
🟢 Taste Rating: Good taste — this is a focused, pragmatic fix that reuses the real secret serialization path instead of maintaining another drift-prone checklist.
Summary
- The
require_secret_key/require_cipherpath is additive and default-off, so existing zero-config OSS/self-hosted behavior is preserved. - Secret detection now flows through the same serializers used for persistence, which correctly covers LLM keys, critic keys, MCP secret maps, custom secrets, and
agent_context.secretswithout adding per-field special cases. - The new tests exercise both settings and custom-secret stores, plaintext backward compatibility, cipher-backed round-trips, and previously missed non-LLM secret fields. GitHub CI for the PR is green; I could not run the local pytest suite in this sandbox because the checked-out environment lacks
pytest/project dependencies, and I avoided installing them during review.
[RISK ASSESSMENT]
- [Overall PR]
⚠️ Risk Assessment: 🟡 MEDIUM
This touches secrets handling and persistence, which is security-sensitive, but the blast radius is constrained: the stricter behavior is opt-in, existing defaults are preserved, no dependencies changed, and the implementation follows the existing Pydantic serializer/cipher pattern with direct regression coverage.
VERDICT:
✅ Worth merging: Core logic is sound, backward-compatible by default, and covered by meaningful regression tests.
KEY INSIGHT:
The probe-cipher approach has good taste because it removes a duplicated notion of “which fields are secrets” and lets the existing serialization data structure own that decision.
This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation
require_secret_key was an opt-in flag that only helps operators who remember to enable it -- the same failure mode as forgetting OH_SECRET_KEY itself, so it added a second forgettable switch rather than closing a gap. Removed: Config.require_secret_key, the require_cipher constructor params and raise branches on FileSettingsStore/FileSecretsStore, and _get_require_cipher. Kept has_any_secret (PersistedSettings/Secrets), which fixes a real pre-existing bug independent of require_secret_key: the plaintext-save warning was gated on llm_api_key_is_set alone, so critic_api_key, MCP server secrets, and agent_context.secrets triggered no warning at all. Tests renamed/rewritten to cover has_any_secret detection directly and the now-correct warning behavior, dropping the require_cipher-raise cases.
|
Simplified in b481a03 — dropped |
|
✅ Review complete. This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here. |
all-hands-bot
left a comment
There was a problem hiding this comment.
🟢 Taste Rating: Good taste — focused fix that broadens plaintext-save warnings by reusing the existing secret serialization pipeline.
Summary
has_any_secretuses the real Pydantic secret serializers with a probe cipher, avoiding another drift-prone per-field checklist and catching future secret-bearing fields automatically.- The store changes are minimal and backward-compatible: plaintext fallback behavior is unchanged; only the warning gate is corrected.
- Regression coverage hits LLM, critic, MCP, agent-context, custom-secret, no-secret, warning, and cipher-backed round-trip paths. I also ran
uv run pytest tests/agent_server/test_persistence_secret_detection.py -qlocally: 11 passed.
[RISK ASSESSMENT]
- [Overall PR]
⚠️ Risk Assessment: 🟡 MEDIUM
Touches security-sensitive secret persistence/warning logic, but the blast radius is narrow and behavior remains backward-compatible because it only broadens warnings when saving without a cipher.
VERDICT:
✅ Worth merging: Core logic is sound and tested; no blocking code-review issues found.
KEY INSIGHT:
The probe-cipher approach has the right data flow: the same serializers that would persist secrets are now the source of truth for detecting them.
This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation
HUMAN:
Split this out of OpenHands/OpenHands#15722 after closing it. Simplified after two more rounds of scrutiny caught that the original scope was overbuilt — see "Why" for the full trail. What's left is a single, narrow bug fix.
AGENT:
Why
This started as two things: a new opt-in
require_secret_keyflag to hard-fail instead of silently persisting secrets in plaintext, plus a fix to the check that decides when to warn about plaintext storage. The flag didn't survive scrutiny; the fix does.What got dropped, and why. The flag's justification was "closes a gap for multi-tenant/Cloud deployments" — that didn't hold up:
AgentServerConfig._default_secret_key()already falls back toSESSION_API_KEY/OH_SESSION_API_KEYS_0as the cipher key, and OpenHands's own Cloud runtime-api already setsSESSION_API_KEYunconditionally on every pod, socipheris already non-Noneon every real Cloud runtime regardless of this flag. Reframed as a general SDK opt-in for third-party deployers instead — but that didn't hold up either: anyone sophisticated enough to discover and enableOH_REQUIRE_SECRET_KEYalready understands the tradeoff well enough to just setOH_SECRET_KEYdirectly, which is strictly simpler and gets the same result. There's no realistic operator in between "doesn't know/care about encryption keys" and "knows enough to want fail-closed behavior but not enough to just configure the key." The flag was a second forgettable switch guarding against forgetting the first one — same failure mode, not a fix for it. Dropped entirely:Config.require_secret_key, therequire_cipherconstructor params and raise branches on both file stores,_get_require_cipher. (The matching Cloud-wiring PR, runtime-api#722, was closed for the same reason before this SDK PR was simplified.)What's left: the bug fix. Independent of the flag entirely, the existing "saving secrets in plaintext" warning was gated on
llm_api_key_is_set— one hardcoded field. Any other secret-bearing field (verification.critic_api_key, MCP serverenv/headers,agent_context.secrets) got persisted in plaintext with zero warning, under plain default behavior, before this PR touched anything.Fixed via
has_any_secret(on bothPersistedSettingsandSecrets): a probe cipher passed ascontext={"cipher": probe}forces every secret field's ownfield_serializerdown its real "encrypted" branch, so detection reuses the actual serialization logic instead of a hand-maintained list of "known" secret fields — the kind of list that's exactly how the original gap happened as MCP/critic/agent_context secrets were added over time without anyone updating the warning check. A naiveisinstance(v, SecretStr)tree-walk was tried first and rejected — it missesagent_context.secrets, whose bare-string values are plainstrat rest and only become secret-shaped inside their own field serializer at dump time.Summary
_contains_secret_value/has_any_secret: generic secret detection via a probe cipher through the real serialization pipeline, replacing thellm_api_key_is_set-only check that gated the plaintext-save warning.REST API contract changes
Compared with base OpenAPI
6d3881035982for public/api/**paths.Issue Number
Fixes #4609 (scoped down from its original ask — see "Why" and the issue's own resolution note).
How to Test
Test coverage:
tests/agent_server/test_persistence_secret_detection.py(11 tests) —has_any_secretdetection acrossllm.api_key,critic_api_key, MCP server secrets,agent_context.secrets, and the empty case, for bothPersistedSettingsandSecrets; plus behavioral tests confirming the warning now fires for non-llm.api_keysecrets (regression coverage for the actual bug) and stays silent when no secrets are present.Ran locally:
pytest tests/agent_server/test_persistence_secret_detection.py— 11/11 passedpytest tests/agent_server/test_settings_router.py tests/agent_server/test_profiles_router.py tests/agent_server/test_agent_profiles_router.py tests/agent_server/test_credential_binding.py tests/agent_server/test_mcp_oauth_store.py— 284/284 passedpre-commit run --files <changed files>— clean.github/scripts/check_persisted_settings_compat.py— passes.github/scripts/check_agent_server_rest_api_breakage.py/check_sdk_api_breakage.py— no breaking changes (the removed flag/params were never in a released version)🐳 Agent Server images for this PR — GHCR package, pull/run commands, and all pushed tags (click to expand)
• GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server
Variants & Base Images
eclipse-temurin:17-jdknikolaik/python-nodejs:python3.13-nodejs22-slimgolang:1.21-bookwormPull (multi-arch manifest)
# Each variant is a multi-arch manifest supporting both amd64 and arm64 docker pull ghcr.io/openhands/agent-server:b481a03-pythonRun
All tags pushed for this build
About Multi-Architecture Support
b481a03-python) is a multi-arch manifest supporting both amd64 and arm64b481a03-python-amd64) are also available if needed